昨天,我們讓清單自己去量每一列的實際高度。TanStack Virtual 讓每一列各自被觀察,量到的尺寸一列記一筆;react-virtuoso 只觀察裝著列的容器,把相鄰同高的列併成一段。不管哪一種,第 3 列折成三行之後,下面的列都接回了正確的位置。
位置算對了。但你正在讀的內容,也會跟著留在原地嗎?
讓我們回到每一列各自被觀察的 List,讓第 10 列停在視窗上緣附近,再讓它上方的第 9 列變高,看看會發生什麼事。
這次先讓二十列都只有一行字,每列實際高 50px。不過,還沒被量到的列,清單仍沿用昨天的 40px 估計值。本文的列編號從 0 開始,和程式裡的 index 一樣。
為了在畫面停住的時候改變某一列的內容,我們把 rows 改成狀態。長文字沿用昨天第 3 列那一段:
// List.tsx:模組層,取代原本的 rows
const LONG =
":這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。";
const initialRows = Array.from({ length: 20 }, (_, i) => `第 ${i} 列`);
建立清單時的 count 和昨天一樣固定為 20。元件裡用 append 在某一列後面接上一段文字,再加一顆按鈕,替第 9 列接上長文字:
// List.tsx:元件內
const [rows, setRows] = useState(initialRows);
const append = (index: number, text: string) =>
setRows((current) =>
current.map((row, i) => (i === index ? row + text : row)),
);
// 放在捲動容器前,兩者用 Fragment 包起來
<button onClick={() => append(9, LONG)}>第 9 列變長</button>
打開頁面,慢慢往下捲到 scrollTop 520px。此時第 10 列從 500px 開始,上緣在視窗之上 20px,它露出來的那一行字,是視窗裡的第一行。第 9 列整列在視窗之上,但因為過掃描 (overscan) 多畫一列,它仍然在 DOM 裡,也仍然被觀察著。
按下「第 9 列變長」。第 9 列折成三行,主控台裡可以看到 量到第 9 列:102px。
接著,第 10 列被推到視窗上緣下方 32px 的地方。原本在視窗第一行的那行字,往下移了 102 − 50 = 52px。
每一列的位置都沒有算錯。第 9 列從 450px 開始、高 102px,第 10 列就接在 552px。
錯開的是另一件事。清單算的位置,是從清單頂端量起的;你讀到哪裡,則是從視窗上緣看的。第 9 列變高,清單頂端到第 10 列的距離多了 52px,scrollTop 卻還停在 520px。
兩個位置之間的關係,可以寫成:
內容在視窗裡的位置 = 內容在清單裡的位置 − scrollTop
第 10 列的位置多了 52px,scrollTop 沒有變,它在視窗裡的位置就往下移了 52px。所謂補償,就是讓 scrollTop 也多 52px,抵銷這次位移,讓你正在讀的位置留在原地。
量到不一樣之後,捲動位置該不該跟著補償?
比較直覺的答案是:變高的那一列,上緣如果在視窗上緣之上,就把 scrollTop 加上它變高的量。視窗跟著內容一起往下移,你讀的那一行就會留在原地。
要寫入 scrollTop,元件得先拿到捲動容器本身。List.tsx 從 react 多匯入 useRef,在元件裡建立一個參考 (ref),交給外層捲動容器的 ref:
// List.tsx:元件內
const scroller = useRef<HTMLDivElement>(null);
該不該補,我們先寫成一個獨立的函式。它收到變高的那一列在哪裡,以及目前的 scrollTop:
// List.tsx:模組層
type Item = { start: number; end: number };
const shouldAdjust = (item: Item, scrollTop: number): boolean =>
item.start < scrollTop;
start 與 end 是這一列變高之前的上緣與下緣。上緣在 scrollTop 之上,就補償。
接著改寫 observer 的回呼:
// List.tsx:元件內,取代原本的 observer
const [observer] = useState(
() =>
new ResizeObserver((entries) => {
const el = scroller.current;
if (!el) return;
let adjustment = 0;
for (const entry of entries) {
const row = entry.target as HTMLElement;
const index = Number(row.dataset.index);
const size = entry.borderBoxSize[0]?.blockSize;
if (size === undefined) continue;
const before = list.sizeOf(index);
const delta = size - before;
if (delta === 0) continue;
const start = list.offsetOf(index);
const item = { start, end: start + before };
const adjust = shouldAdjust(item, el.scrollTop + adjustment);
list.measure(index, size);
if (adjust) adjustment += delta;
console.log(
`第 ${index} 列:${before} → ${size}px,${adjust ? `補償 ${delta}px` : "不補償"}`,
);
}
flushSync(rerender);
if (adjustment !== 0) el.scrollTop += adjustment;
}),
);
每量到一列,先和目前採用的尺寸 sizeOf 比較。尺寸相同就跳過;尺寸變了,就在寫入新尺寸之前,用舊的尺寸算出它的上緣與下緣,交給 shouldAdjust。
同一次回呼可能有好幾列要補,所以補償量先加總在 adjustment,最後只寫一次 scrollTop。判斷後面的列時,前面已經決定要補的量,也算進 scrollTop 裡。
寫入之前,先用 react-dom 匯出的 flushSync 讓 React 同步完成這次重新渲染。ResizeObserver 的回呼發生在畫面繪製之前,這樣新的位置和新的 scrollTop,會在同一次繪製裡一起生效,避免先閃過一格錯開的畫面。
渲染時印出範圍的那一行 log,今天用不到,先拿掉。
重新整理頁面,同樣捲到 520px,再按一次「第 9 列變長」:
第 9 列:50 → 102px,補償 52px
scrollTop 從 520px 變成 572px,第 10 列仍然在視窗上緣之上 20px。
這條規則也照顧到另一種情況。重新整理頁面,直接把捲軸拖到最底,再一口氣往上捲 130px。這時從上方進場的幾列,都是第一次被量到:
第 11 列:40 → 50px,補償 10px
第 12 列:40 → 50px,補償 10px
補償抵銷了量測造成的額外位移。這次操作裡,原本在畫面上的第 15 列往下移了 130px,對應的是使用者往上捲的距離。
換成還沒有補償的版本做同一件事,新進場的第 10 到 12 列各比估計值多 10px。同樣捲了 130px,第 15 列卻往下移了 160px,畫面像是多跳了一下。
再看另一種長高。聊天介面裡,訊息有時是串流進來的:文字一段一段接在後面,那一則訊息從下緣往下長。
我們再加一顆按鈕,每按一次,就在第 10 列後面接上一段文字:
// List.tsx:模組層
const CHUNK = "串流中的訊息,每收到一段文字就長高。";
// 放在「第 9 列變長」旁邊
<button onClick={() => append(10, CHUNK)}>第 10 列多一段</button>
重新整理頁面,同樣往下捲到 520px。第 10 列橫跨視窗上緣,你讀的是它露出來的那一行。按三次「第 10 列多一段」:
第 10 列:50 → 76px,補償 26px
第 10 列:76 → 102px,補償 26px
第 10 列:102 → 128px,補償 26px
每按一次,scrollTop 就多 26px。三次之後,第 10 列的上緣在視窗之上 98px,你原本讀的那一行,已經被推出視窗頂端。
新的文字明明接在你讀的那一行下面。
第 10 列的上緣確實在視窗之上,但它長高的地方,在你讀的位置下方。這時補償,等於每長一次,就把視窗往下拖一次。
那麼,改成整列都在視窗之上才補呢?把條件換成 item.end <= scrollTop,串流的第 10 列就不補了,整列在上方的第 9 列照樣補。
可是回到剛才往上捲的情況,拖到最底、一口氣往上捲 130px:
第 11 列:40 → 50px,補償 10px
第 12 列:40 → 50px,不補償
第 12 列第一次被量到的時候,也橫跨視窗上緣。整列不在上方,所以沒有補;原本在畫面上的列,又多往下移了 10px。
同樣是橫跨上緣、量到變高的一列,第 12 列要補,第 10 列卻不能補。差別在哪裡?
第 12 列在這之前沒有被量過,它的 40px 是我們猜的。量到 50px,代表的不是它剛剛長高了,而是先前少算了 10px。
它是往上捲時才進場的。我們想保住的是原本已經在畫面裡、位於它下方的內容。估計少算的 10px,會把那些內容額外往下推,所以要補回去,讓它們只跟著使用者的捲動移動。
第 10 列不一樣。它已經被量過,也已經在畫面裡,我們正在讀它。新增文字接在閱讀位置下方,原本那一行沒有往下移;這時再補 scrollTop,反而會把它往上推出去。
同樣是尺寸差值,背後代表的事情不同:
要保護的是正在讀的位置,不一定是整列的上緣。
所以,兩種情況要分開判斷。Item 多記一件事,這一列是不是第一次被量到:
// List.tsx:模組層
type Item = { start: number; end: number; firstMeasure: boolean };
const shouldAdjust = (item: Item, scrollTop: number): boolean =>
item.firstMeasure ? item.start < scrollTop : item.end <= scrollTop;
回呼裡建立 item 的那一行,跟著補上 firstMeasure。sizes 裡還沒有這一列,代表它是第一次被量到。這個判斷必須放在 list.measure 寫入新尺寸之前:
// List.tsx:observer 回呼內,取代原本的 item
const item = {
start,
end: start + before,
firstMeasure: sizes.get(index) === undefined,
};
重新整理頁面,往下捲到 520px,先按三次「第 10 列多一段」,再按一次「第 9 列變長」:
第 10 列:50 → 76px,不補償
第 10 列:76 → 102px,不補償
第 10 列:102 → 128px,不補償
第 9 列:50 → 102px,補償 52px
第 10 列從頭到尾停在視窗上緣之上 20px,被推下去的是它下面的第 11 列。第 9 列變高時,補償了 52px。拖到最底再往上捲 130px,第 11、12 列也都補償了 10px,第 15 列正好移了 130px。
TanStack Virtual 的 resizeItem 裡,也是兩套規則,先來看判斷的主幹:
// @tanstack/virtual-core: src/index.ts,省略註解
const isFirstMeasure = !this.itemSizeCache.has(key)
const defaultShouldAdjust = isFirstMeasure
? itemStart < scrollOffsetWithAdj
: itemStart + itemSize <= scrollOffsetWithAdj &&
this.scrollDirection !== 'backward'
原程式碼旁的註解說明了理由。第一次量到,上緣在視窗之上就補,讓後續內容的位置不再受估計差值影響。重新量到,整列在視窗之上才補;只是橫跨上緣的那一列,例如從底部長高的串流訊息,變的是閱讀位置下方,補償反而會在每次長高時把視窗往下拖。註解裡的 #1218 是促成這條規則的 issue 編號。
最後一個條件我們沒有寫:往回捲動(backward,也就是往上捲)的時候,重新量到的列也不補。
💡 這份
List是為了看清楚判斷落在哪裡的最小實驗,省略了往回捲動時不補、平滑捲動與行動裝置上的處理。同步重新渲染再寫入scrollTop,則是為了讓新位置與補償在同一次繪製裡生效。原作在相關註解裡,也說明了兩者分開繪製可能造成的錯位(#1227)。
defaultShouldAdjust 的名字裡有一個「default」。緊接著,resizeItem 先看呼叫端有沒有給自己的判斷,沒有才用它。那個判斷是 virtualizer 實例上的一個公開屬性:
// @tanstack/virtual-core:Virtualizer 實例上的屬性
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
item: VirtualItem,
delta: number,
instance: Virtualizer<TScrollElement, TItemElement>,
) => boolean)
它收到變了尺寸的那一列、變化量與 virtualizer 本身,回傳要不要補償。指定了它,上面那兩套預設規則就不再使用。
把這個名字逐字讀一遍:should adjust scroll position on item size change。列的尺寸變了,該不該調整捲動位置?
這正是今天的標題。
我們一開始把 shouldAdjust 寫成獨立的函式,只是為了方便換規則。TanStack Virtual 把同一個問題放上了公開介面,預設答案的理由,就寫在原始碼的註解裡。
為什麼要讓它可以換掉?
預設規則押的是:重新量到的那一列,是從下方長高的。對串流訊息來說,這押對了。
但假使第 10 列是一則上方帶著圖片的訊息,同樣橫跨視窗上緣。圖片載入後,從上方把這一列撐高;你讀的那一行文字在圖片下面,長高發生在你讀的位置之上。預設規則仍然不補,那一行字就會被推下去。
ResizeObserver 回報的是一列變成多高,不是它在哪裡長高。知道內容怎麼長的,通常是呼叫端。
回頭看,我們一路寫下來、最順手的那一份實作,其實一直把一個決定攤在介面上:預設值替常見的情況做了選擇,理由寫在註解裡,換掉它的地方是一個公開的屬性。
react-virtuoso 的介面上,找不到類似的屬性。
它不是沒有和量測相關的選項:defaultItemHeight、heightEstimates 讓呼叫端提供估計值,fixedItemHeight 讓它跳過量測,itemSize 可以換掉讀取尺寸的方式,skipAnimationFrameInResizeObserver 決定回呼要不要延到下一幀。但「量到不一樣之後,該不該補償」這一題,沒有任何一個選項在回答。
它的答案放在 upwardScrollFixSystem 裡。在這條向上捲動的修正路徑裡,它關注的是整份清單的高度差,而不是讓呼叫端逐列判斷長高的位置。是否進入修正,還要通過下面幾個條件:
// react-virtuoso: src/upwardScrollFixSystem.ts
return !recalcInProgress && !scrollingInProgress && scrollTop !== 0 && scrollDirection === UP
前兩個條件排除的是元件自己正在重新計算或程式化捲動的時候。後兩個條件是:不在最頂端,而且方向是往上。
我們照這個形狀,改寫昨天只觀察容器的 ContainerList。rows、append 與兩顆按鈕和 List 相同,匯入也同樣多了 useRef 與 flushSync。
不過在補償之前,還有一件事要先處理。ContainerList 的列排在正常的文件流裡,而 Chrome 有自己的捲動錨定 (scroll anchoring):錨點上方的內容變高時,瀏覽器會自動調整捲動位置。不關掉它,我們會看不出自己的規則做了什麼。react-virtuoso 在每一列都設了 overflowAnchor: 'none',我們照做:
// ContainerList.tsx:模組層
const rowStyle = {
padding: "12px 16px",
lineHeight: "26px",
overflowAnchor: "none",
} as const;
接著要知道使用者正在往哪個方向捲。我們記下上一次的 scrollTop,捲動時比較一次;自己補償寫入的那一次,也要先更新 lastTop,免得被當成使用者往下捲:
// ContainerList.tsx:元件內
const scroller = useRef<HTMLDivElement>(null);
const lastTop = useRef(0);
const goingUp = useRef(false);
// 外層捲動容器
<div
ref={scroller}
style={{ width: 320, height: VIEWPORT_HEIGHT, overflowY: "auto" }}
onScroll={(event) => {
const top = event.currentTarget.scrollTop;
if (top !== lastTop.current) goingUp.current = top < lastTop.current;
lastTop.current = top;
setScrollTop(top);
}}
>
最後是 observer 的回呼。讀取與比對 data-known-size 的部分和昨天相同,前後多了總高度的比較:
// ContainerList.tsx:元件內,取代原本的 observer
const [observer] = useState(
() =>
new ResizeObserver((entries) => {
const container = entries[0]?.target;
const el = scroller.current;
if (!(container instanceof HTMLElement) || !el) return;
const before = list.totalSize();
let reported = 0;
for (const child of container.children) {
const row = child as HTMLElement;
const size = row.offsetHeight;
if (size === Number(row.dataset.knownSize)) continue;
list.measure(Number(row.dataset.index), size);
reported += 1;
}
if (reported === 0) return;
const deviation = list.totalSize() - before;
const adjust = deviation !== 0 && goingUp.current && el.scrollTop !== 0;
console.log(
`回報 ${reported} 列,總高度 ${deviation > 0 ? "+" : ""}${deviation}px,${adjust ? "補償" : "不補償"}`,
);
flushSync(rerender);
if (adjust) {
el.scrollTop += deviation;
lastTop.current = el.scrollTop;
}
}),
);
同樣往下捲到 520px,先按三次「第 10 列多一段」,再按一次「第 9 列變長」:
回報 1 列,總高度 +26px,不補償
回報 1 列,總高度 +26px,不補償
回報 1 列,總高度 +26px,不補償
回報 1 列,總高度 +52px,不補償
串流的第 10 列沒有補,它的上緣一直停在視窗之上 20px。第 9 列變高也沒有補,第 10 列被推到視窗上緣下方 32px,和這一篇開頭看到的一樣。
重新整理,拖到最底再往上捲 130px:
回報 3 列,總高度 +30px,補償
第 15 列正好移了 130px。
💡 react-virtuoso 的捲動方向在捲動停下後不久會歸零,我們的
goingUp只記得最後一次捲動的方向:如果最後是往上捲到 520px,停住之後第 9 列變高也會補償,所以上面一律往下捲。原作另外排除了畫面從第 0 列開始的情況。
在這三個指定的操作情境裡,兩份最小實作的結果可以整理成:
| 操作情境 | 逐列判斷的 List |
依方向判斷的 ContainerList |
|---|---|---|
| 往下捲後停住,上方的第 9 列變高 | 補償 | 不補償 |
| 跨上緣的第 10 列從下方長高 | 不補償 | 不補償 |
| 往上捲,新進場列第一次量到較大的尺寸 | 補償 | 補償 |
往上捲與串流的結果相同,差別出現在往下捲後停住,再讓視窗上方的列變高。
從 upwardScrollFixSystem 的這條規則來看,它優先處理的是往上捲時,新進場列的量測差異。這套判斷符合需求時,呼叫端不必介入;但如果需要另一套逐列補償政策,前面列出的選項沒有提供同樣的替換入口。
它把量測與補償之間的協調留在內部。呼叫端不必安排尺寸更新與捲動修正的順序,但也不能像前面的 shouldAdjust 一樣,直接換掉這條判斷。
💡 原作還處理了量測的批次與重複通知:
ResizeObserver回呼預設透過requestAnimationFrame延到下一幀,尺寸狀態送往下游前也會比對,沒有變就不送(distinctUntilChanged)。我們的
ContainerList只保留較小的形狀:一次回呼讀完所有列,只算一次總高度、只寫一次scrollTop;data-known-size相同的列不回報,一列都沒回報就直接返回。
回頭看這兩份實作。
TanStack Virtual 藏起了量測的機制:每一列由誰觀察、尺寸記在哪裡、scrollTop 要補多少,呼叫端都不必自己處理。但「該不該補償」這個判斷,它交了出來,附上預設值與理由。
react-virtuoso 連這個判斷也藏了起來。呼叫端不必替它做決定,也看不到量測與補償之間的來回;代價是預設的判斷不合用時,介面上沒有可以施力的地方。
兩邊都替呼叫端承擔了很多。沿用這個系列的尺,它們都有深模組的特徵:介面背後承擔了大量量測與位置計算的工作。
探索瀏覽器 API 的最後一天,我們說過那把尺會在真實 DOM 這一端再量一次。那把尺問的是:呼叫端還需要知道什麼?
放到這裡,量到的是:預設的判斷不合用時,知道內容怎麼變的呼叫端,有沒有地方接手。ResizeObserver 回報了一列的新高度,卻不知道多出來的內容長在閱讀位置上方,還是下方。TanStack Virtual 留了一個入口,讓呼叫端補上這份知識;react-virtuoso 把這個判斷留在內部。
可是補償一寫進 scrollTop,要畫哪幾列也跟著變了,新畫出來的列又會被量到。量到的結果,改變了下一次要量的東西。兩邊各自在哪裡停下來?
原作的出處:TanStack Virtual 的
virtual-core/src/index.ts(resizeItem)與 API 文件(shouldAdjustScrollPositionOnItemSizeChange);react-virtuoso 的upwardScrollFixSystem.ts、Virtuoso.tsx(overflowAnchor)與component-interfaces/Virtuoso.ts(元件的選項)。取自預設分支原始碼,npm 最新版為@tanstack/virtual-core3.18.0、react-virtuoso4.18.16,查核於 2026-10-10。本文的實驗跑在
react19.3.0 與react-dom19.3.0,Chrome 154,字型 PingFang TC 16px,查核於 2026-10-10。